Conversation
d-v-b
force-pushed
the
zarr-metadata/rules-wrap-blosc
branch
from
September 19, 2026 19:43
f39ce0d to
59a1288
Compare
Documentation build overview
12 files changed ·
|
d-v-b
added a commit
to d-v-b/zarr-python
that referenced
this pull request
Sep 20, 2026
`typesize` is the only conditionally-required configuration member of any entity this package models: "Positive integer specifying the stride in bytes over which shuffling is performed. Required unless `shuffle` is `"noshuffle"`, in which case the value is ignored." A TypedDict cannot express that, so `BloscCodecConfiguration` declares it `NotRequired` and nothing supplied the condition — leaving the member the spec singles out as required as the one member of a blosc configuration that could always be omitted, while `blocksize`, which the spec never marks required, was. A rule supplies it, alongside the value constraints the shape validator cannot state: `clevel` in [0, 9], `typesize` positive, `blocksize` non-negative. blosc leaves the deliberately-rule-free list. These rules were written for the stacked zarr-developers#4380, which is based on this PR's pre-redesign head and would not apply to it; they are brought down here because this is the PR that makes `typesize` optional, and zarr-developers#4380 should drop its blosc half when it rebases. Verified against the spec's own example document and against what zarr-python writes for blosc with and without shuffling. Assisted-by: ClaudeCode:claude-opus-5 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
d-v-b
force-pushed
the
zarr-metadata/rules-wrap-blosc
branch
from
September 20, 2026 12:20
59a1288 to
bde5d5d
Compare
d-v-b
force-pushed
the
zarr-metadata/rules-wrap-blosc
branch
from
September 20, 2026 12:26
bde5d5d to
7009a21
Compare
…dators, create_* factories Adds `zarr_metadata.rules`: cross-field judgments over whole documents (fill value vs data type, codec pipeline ordering, chunk-grid geometry, sharding and transpose against the array each codec actually receives, struct field rules, dimension-name counts), registered where they are defined and dispatched per entity; `validate_*` / `is_*` / `parse_*` / `check_*` front doors for readers; `v3._shape` validators derived from the entity TypedDicts; and one `create_*` factory per document TypedDict. The two rank checks (v3 `dimension_names` vs `shape`, v2 `chunks` vs `shape`) move from the structural validator into the rules layer. The pydantic field types now run the rules layer before normalizing, so they are strictly stronger than before rather than weaker. Relative to #296 this drops the incremental builder, the extension-point provenance table, `TypeIs` codec guards, fill-value propagation through codec chains (no rule read it), registry introspection helpers, and a duplicated v2 consolidated envelope check; renames the `.zarray`/`.zgroup` factories to `zarray`/`zgroup`; and requires an endianness on the `bytes` codec inside `index_codecs`. Split from #296 (part 2 of 3). Assisted-by: ClaudeCode:claude-fable-5-1
Assisted-by: ClaudeCode:claude-fable-5-1
Assisted-by: Codex:GPT-6
…eate_* factories Upstream enabled ruff S101 for runtime code (zarr-developers#4363). The six parse-then-raise factories share a _parsed_or_raise helper that narrows the parsed document, and the rule-registration import uses importlib.import_module instead of an assert to keep it referenced. Assisted-by: ClaudeCode:claude-opus-5 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…4379 Assisted-by: ClaudeCode:claude-opus-5 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The builder is a construction feature in a read-side PR, and its factories re-implemented the structure-plus-composition combination this PR already exposes as `rules.validate_*` / `rules.parse_*`. Held back for a later PR alongside the incremental builder. `test_validator_and_factory_agree` becomes `test_validator_and_parser_agree`: the same property against the front door the package keeps. Assisted-by: ClaudeCode:claude-opus-5 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A fourth read-side front door for a question `validate_*` and `parse_*` already answer, with two new public types (`Valid`, `Invalid`) and no consumer. Removed with its tests and changelog fragment. Assisted-by: ClaudeCode:claude-opus-5 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The `is_*` counterparts were `bool`, not `TypeIs`, and each docstring had to explain that they do not do what the name promises; `model.is_*` remains for narrowing. Their removal leaves `validate_*` and `parse_*`, which now share `_judged` over an already-normalized document instead of walking it twice per call. Assisted-by: ClaudeCode:claude-opus-5 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- `initial_spec` was a four-line tail of `chain_initial_spec` living in another module; folded into its only caller. - `STORAGE_TRANSFORMERS` was defined and exported without a reader. - `_engine`'s prior-art bibliography condensed to the claim it supports. Assisted-by: ClaudeCode:claude-opus-5 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The struct spec states its field constraints in the bytes codec's terms rather than inventing its own: field types must have "a fixed encoded size", and "When a `struct` type contains multi-byte numeric fields, the `bytes` codec MUST be configured with an explicit `endian` setting". So the struct rule's fixed-size question and the bytes rule's endianness question are one classification, and the two hand-written tables of data-type sizes were the same table twice. `rules._storage_class` now owns it, keyed off the data-type modules' own name constants, and `test_registry_drift` fails if a new data type arrives without a class — previously it would have silently gone unjudged by both rules. Assisted-by: ClaudeCode:claude-opus-5 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The strict reading was already the behavior; the prose hedged, telling callers they "can filter" a kind that every raising path rejects. State the decision instead: an unmodelled member of a known entity's configuration is almost always a typo or a setting meant for a different entity, so `parse_*` and the pydantic field types both refuse it, and the dedicated kind is there for triage via `validate_*`. Two tests pin what was untested: that the pydantic field types run the rules layer at all, and that they reject an unknown configuration member. Also syncs the README and docs feature lists, which still described the pydantic integration as delegating to the model parser and omitted the rules layer entirely, and notes that the generated JSON Schemas leave configurations open, so schema-valid input can still fail at runtime. Assisted-by: ClaudeCode:claude-opus-5 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Drop a `pyright: ignore[reportPrivateUsage]` on a public name; pyright is configured for `src` only, so the comment was never evaluated. - Keep `Sequence` under TYPE_CHECKING alongside `Callable` and `Rule`. - Rewrap the changelog fragment and say "two validation layers", so it does not read as contradicting the README's three-layer overview. - "different entity", consistently, in the pydantic strictness test. Assisted-by: ClaudeCode:claude-fable-5-1 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The previous wording — "every entry point that raises rejects it, the pydantic field types included" — is false. Three raising entry points accept an unmodelled configuration member: `model.parse_*`, `ZarrV3ArrayMetadata.from_json`, and the bare `ZarrV3MetadataField` pydantic type. Only the rules layer judges configurations, so only `rules.parse_*` and the whole-document pydantic field types reject it. Say that instead, in the README, the docs site, the `ProblemKind` docstring and the changelog fragment, and assert the `ZarrV3MetadataField` half of the boundary so it cannot move unnoticed. Assisted-by: ClaudeCode:claude-opus-5 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found by adversarial review; each has a regression test.
- scale_offset registers the identity spec transition the spec describes
("MUST be performed using the arithmetic semantics of the input array's
data type"; astype was removed in v3 in favour of cast_value). Without
it a no-op codec stopped propagation and stood down every later rule.
The test exemption that recorded this is gone.
- chain_initial_spec keeps the rank when it cannot keep the extents: an
ArraySpec extent may now be None individually, so a rectilinear grid or
a zero extent no longer hides a rank-mismatched transpose or shard.
- Entity rules declare the configuration members they read, and
run_entity_rules stands down only the rules that read an unusable
member rather than the whole entity. This also makes the invariant the
configuration["member"] accesses rely on explicit and checked at
registration, where it was previously true only by inspection.
- A rule reporting at the entity itself keeps that location instead of
being re-based under a "configuration" node a bare-string entity does
not have.
- The endianness problem names the data type, so the shard-index case
reads as uint64 rather than appearing to contradict the document.
Assisted-by: ClaudeCode:claude-opus-5
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…sking Two defects in the per-member gate added in 08d335d, both found by re-running the adversarial review against it. `run_entity_rules` decided "the configuration is unreadable" from the *length* of a problem's location, so `("must_understand",)` qualified and a malformed envelope flag stood down every composition rule for that entity. Test the two locations that actually mean it instead. `reads` promised that declaring a member makes `configuration[member]` safe, but it was validated against every modelled member rather than the required ones. A rule declaring an optional member still raised KeyError out of validate_*, which must never raise. `reads` now accepts only required members; `reads_optional` covers the presence-tested case (the bytes codec's `endian`), and registration refuses the unsafe spelling with a message naming the alternative. Assisted-by: ClaudeCode:claude-opus-5 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three sites derived "the shape of the chunk this pipeline encodes" by hand — the document's grid, a shard's inner chunk shape, and the shard index — and each got a different subset of the reasoning right. Patching them one at a time produced three near-identical fixes and missed two more cases, so model the thing itself. `rules._chunk_grid` answers the question per dimension instead of per grid: a `GovernedShape` has one entry per axis, `None` where the chunks differ or the metadata cannot be read, and is itself `None` only when not even the rank is known. Following zarrs, a grid is read from its metadata together with the array shape it partitions (neither determines a grid alone), its rank is always available, and its extents are reported dimension by dimension rather than as one shape. Two cases the previous derivations could not express now work. A rectilinear grid whose chunk shapes are uniform pins the shard shape, in all three spellings the spec allows, while one uniform on a single axis is judged there and declines elsewhere. A shard index is judged against chunks-per-shard plus a trailing dimension of 2, as the spec derives it, instead of against nothing. Also extracts entity configuration access into rules._entity so the grid module can read a grid's configuration without importing the dispatcher. Assisted-by: ClaudeCode:claude-opus-5 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A codec pipeline encodes one chunk, but the same pipeline encodes every
chunk, so a chain rule is a statement about all of them. The propagated
value said otherwise: `ArraySpec` held one chunk's shape, so an axis whose
chunks differ collapsed to "unknown" and the rules that could have judged
it stood down.
`ArrayParts` carries the `ChunkGrid` instead. A grid holds its rank, its
metadata as written, and per dimension the set of lengths that dimension's
chunks take — singletons for a regular grid, `{30, 34}` for a rectilinear
axis, `None` only where nothing can be read. Divisibility quantifies over
that set and names the length that fails.
This catches a case no previous shape could express: a rectilinear grid,
a transpose moving its varying axis, and a sharding codec that has to
divide what arrives. Inner extents of 15, 30 and 34 against chunks of 30
and 34 were all accepted before; only a common divisor passes now.
Two things fall out of holding the grid rather than a projection of it.
Sharding stops being a special case — a shard is a nested array, so its
inner pipeline is built by the same constructor as the document's own —
and a third-party grid is carried verbatim instead of being flattened at
the first hop, so a future rule can read its own configuration.
`data_type` becomes non-optional: the only documents that cannot supply
one are documents the structural layer has already rejected, so rather
than a half-populated value, a codec that can no longer be described
receives nothing at all. `NOTHING_KNOWN` is gone in favour of `| None`,
and rules test one guard instead of a field at a time.
Named for what it is, after zarrs: a grid is built from metadata *and* the
array shape it divides (neither determines a grid alone), its
dimensionality is total, and its edge lengths are reported per dimension.
`ArrayParts` avoids `chunk`, which would be singular, and `ArraySpec`,
which is taken by zarr.core.array_spec for the runtime type.
Assisted-by: ClaudeCode:claude-opus-5
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`st.from_type` resolves the codec TypedDicts unaided — ReadOnly, closed, NotRequired and Literal all work — once one strategy is registered for the recursive JSONValue alias, without which sharding_indexed, cast_value and scale_offset cannot be resolved at all. The existing totality test feeds arbitrary JSON to the document validators. That is the right guard for the structural layer and no guard for this one: a random object never names a codec, so across 5,000 examples it dispatched no entity rule. Drawing codecs from their own types and assembling the chain by kind takes documents reaching a chain rule from 0% to 94%; ordering is what does it, because a misordered chain is rejected before any other rule runs, and a flat list is misordered most of the time. Adds the property nothing covered: documents valid by construction, with inner chunk shapes drawn from the divisors of the extents they must divide, over both regular and rectilinear grids. Every recent fix made this layer stricter and under 5% of generated documents are valid, so the accept side had no generated coverage at all. Each strategy's reach is asserted, not assumed — the witnesses it must produce, and why the three chain rules it cannot reach are out of reach by construction. Verified by sabotage: stubbing out entity dispatch fails both reach tests, where previously the property suite stayed green. Assisted-by: ClaudeCode:claude-opus-5 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ck tests Differential testing against the pre-ArrayParts tree found 390 documents the old validator rejected and the new one accepted. All of them are one regression: gating the shard's inner and index pipelines on `incoming`. Both are determined by the sharding codec's own `chunk_shape` and by the spec — the index is a `uint64` array whatever reaches the codec — so neither should ever have waited on upstream. It was a consequence of making `data_type` non-optional: with nothing to pair the grid with, both starts collapsed. The reasoning behind that change held for documents and failed inside a shard, where the grid is known and the element type is not, so `data_type` is optional again while `ArrayParts | None` keeps its own meaning of "no array here". Also: an unusable `data_type` costs itself and no longer hides the geometry, and `ChunkGrid.permuted` declines on a non-permutation instead of raising `IndexError` one careless caller away from a validator. Tests are now type-checked. They were covered by nothing — pyright was configured for `src` alone and the repo's mypy hook resolves this package's imports as `Any` — which is how a `TYPE_CHECKING` import of a type deleted three commits ago survived. It also showed that `from_key_value(to_key_value())`, the round trip the models advertise, did not type-check: `Mapping` is invariant in its key type, so a mapping keyed by literal store keys is not a `Mapping[str, bytes]`. Widened. A mutation audit put the suite's kill rate at 67%, with the largest hole in the `reads` gate: `st.from_type` honours the TypedDicts, so no strategy produced an ill-typed configuration member, and four one-token changes to the gate made `validate_*` raise while the suite stayed green. `corrupted_chains` covers it; the three mutations were verified to fail now. Two redundant property tests are gone, and the reach claim in the strategies module is replaced with measured figures — the previous 39% to 94% and its short-circuit explanation did not reproduce. Assisted-by: ClaudeCode:claude-opus-5 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`typesize` is the only conditionally-required configuration member of any entity this package models: "Positive integer specifying the stride in bytes over which shuffling is performed. Required unless `shuffle` is `"noshuffle"`, in which case the value is ignored." A TypedDict cannot express that, so `BloscCodecConfiguration` declares it `NotRequired` and nothing supplied the condition — leaving the member the spec singles out as required as the one member of a blosc configuration that could always be omitted, while `blocksize`, which the spec never marks required, was. A rule supplies it, alongside the value constraints the shape validator cannot state: `clevel` in [0, 9], `typesize` positive, `blocksize` non-negative. blosc leaves the deliberately-rule-free list. These rules were written for the stacked zarr-developers#4380, which is based on this PR's pre-redesign head and would not apply to it; they are brought down here because this is the PR that makes `typesize` optional, and zarr-developers#4380 should drop its blosc half when it rebases. Verified against the spec's own example document and against what zarr-python writes for blosc with and without shuffling. Assisted-by: ClaudeCode:claude-opus-5 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"An integer from -131072 to 22 which controls the speed and level of compression", with 0 selecting the default. The shape validator can only say it is an integer, so the range is a rule, and zstd leaves the deliberately-rule-free list alongside blosc. `checksum` needs none: the spec marks it "(Optional)" and the TypedDict already declares it `NotRequired`. Its "Should be omitted if false" is a SHOULD, and this package reports violations of requirements rather than of advice. Assisted-by: ClaudeCode:claude-opus-5 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ot a record The `Compiled` record, its weak side table and the `compiled()` accessor were memoization dressed up as architecture. What the layer reads off an entity's fields -- `member_types`, `nested_members`, `name_members` -- is three functions of the class under `functools.cache`, and `configuration_required` is a one-line function over the first. Assisted-by: ClaudeCode:claude-fable-5-1 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… the fields as it reads Review question: why is anything defined outside the individual classes? The entity declares its fields and one `__post_init__`; the base's `coerce` type-checks a document's configuration against those fields as it reads them, member by member, resolving the ones that hold entities through the scope, and builds the entity only when every member read. That needs no table: `member_types`, `nested_members`, `name_members`, `configuration_required`, `derive_member_types`, `coerce_members` and the `MemberTypes` alias are gone, along with the cache that held them. The invariants read the same field hints; the first of them refuses a field annotation outside the shapes the compiler reads, with the message that named the table's failure before. Assisted-by: ClaudeCode:claude-fable-5-1 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… names its fields Review question: why is `ExtensionPointField` defined outside the array document's validation logic? It was the array document's field names, carried into the entity layer as the key of every registry table, the `extension_point` class variable on every kind, and five constants. That knowledge belongs to the document. Now a scope holds entities by kind -- `DataTypeEntity`, `ChunkGridEntity`, `ChunkKeyEncodingEntity`, `CodecEntity`, `StorageTransformerEntity`, the last two new, so every entity is of one -- and `read_array_v3` is the one place that says which field holds which kind. `Context.of(*classes)` and `extended_with(*classes)` read each class's kind off its base and its key off its `identifier`, so nothing can be misfiled and the two registration checks for that are gone; `resolve` and `coerce` take the kind (or any subclass, so a field typed `GzipCodec | Opaque` resolves only gzip) and are generic in it, which retires four overloads apiece and the casts that funnelled the document's fields through one dict. The typed-per-point TypedDicts, `_ENTITY_KINDS` and `_point_of` go with them. Assisted-by: ClaudeCode:claude-fable-5-1 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…teral of its own JSON type Review comment: a base `to_json` that builds a `dict[str, object]` and casts it to a type parameter it cannot know is wrong. It is abstract now. Each of the 33 entities writes its own, as a literal of the TypedDict it names -- the bare name when every member is absent, the object otherwise, a contained entity through `written` -- and pyright holds the literal to the TypedDict: a key it does not declare, a required one left out, a value of the wrong type is a static error. Gone with the cast: the class-creation check that compared the named type with the fields and its helpers, `json_type_of`, the generic `configuration()` and the rendering walk it needed, the `must_understand` class variable nothing read any more, and the test oracle that compiled the named type -- the examples table now round-trips each document through `coerce` and `to_json` instead. `ArrayDocumentV3.to_json` writes its five fields by name. Assisted-by: ClaudeCode:claude-fable-5-1 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
An entity names its JSON type once, as the return type of its own `to_json`, and pyright holds the literal to it. Naming it a second time as a type argument of the base bought nothing: the base's `to_json` is abstract, so no method on it ever needed the parameter, and every bare use of a kind (`CodecEntity | Opaque`, a scope's table) wanted the defaulted form anyway. `Generic[JSONT_co]`, the two `TypeIs` helpers the generic base needed to narrow an `object`, and `unsubscripted` go with it; the 38 subscripted bases are plain again. Assisted-by: ClaudeCode:claude-fable-5-1 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…e nested walks are the entity's own The type-annotation reader is one module, `_typed_json`, that knows nothing of entities: `parser_for` turns a field annotation into a parser of JSON values, over the shapes JSON takes and no others. A caller with a shape of its own passes a `leaf`, asked first at every depth. The entity layer's one shape is a field holding another entity, `Kind | Opaque`; its leaf reads the inner entity through the scope and keeps the inner problems apart from the containing entity's own. So a struct's fields' data types and a shard's pipelines are resolved by the same walk that type-checks them, and the three annotation walkers the entity module carried for resolution and canonicalization, with the union-branch and fixed-tuple machinery they needed, are gone. `canonical` is the entity's own, as `to_json` is: the entity itself by default, overridden where two spellings mean the same and, in an entity that contains entities, to put those in canonical form with `canonicalized`. No base walk, no `simplified` hook, no `@final`. Class creation refuses two things: a field whose annotation is not a shape JSON takes, and a class variable a base annotates and nothing sets. The other nine invariants either duplicated pyright (a field shadowing a class variable, a `Literal` class variable outside its values, an override of a final method), duplicated registration (an entity of no kind; a codec skipping the kind classes, now refused there), or guarded defaults `coerce` no longer relies on: an optional member the document left out is passed as `UNSET` explicitly. A codec's pipeline position is its base class alone; the `kind` string it also carried is gone, and the chain rules ask `isinstance`. One diagnostic is more complete: when one element of a shard's pipeline is not a metadata field at all, the other elements are still read and judged, where before the whole member stood down. Over the 40,000 document corpus that adds 133 problems to 91 documents already invalid and changes no verdict; nothing is lost. Assisted-by: ClaudeCode:claude-fable-5-1 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ance that yields problems Every check that lived in an entity's `__post_init__` is a module-level function of the instance -- `blosc_problems(codec)` -- that yields each problem as it finds it, bound on the class as `problems`. One function, two consumers: the constructor takes the first problem it yields and raises `MetadataValidationError`, so `BloscCodec(clevel=99)` still refuses; `coerce` runs it to the end and reports every problem in the document. Anyone holding an entity may run it too, and stop or collect as they need. `coerce` builds the instance without asking the constructor -- the members are already parsed and typed -- and asks `problems` itself, so a document's every problem is reported where the constructor would stop. Class creation refuses an entity that defines `__post_init__`, because nothing would run it on a document. The two numpy time types declare their shared configuration and rule on the family. No verdict or problem changes over the 40,000-document corpus. Assisted-by: ClaudeCode:claude-fable-5-1 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Three reviews of the rebuilt layer, each from the vantage of another implementation (zarrs, TensorStore, zarrita.js), agreed on a short list of defects and complexity; this is the part that needed no decision. Every codec declares `variable_size`. The default of `False` was a verdict: the door's own example compressor, which set nothing, was accepted as a shard-index codec, where `gzip` is refused. Registration is the one moment an entity is refused. `__init_subclass__` and the twelve `base=True` flags are gone; `_registrable` asks `unreadable`, which names a field whose annotation is not a shape JSON takes (a type defined inside a function among them, now a message rather than a bare `NameError`), a `__post_init__` of the entity's own, and an owed class variable left unset, before the abstract methods. Nothing happens at class creation, so a family is a plain subclass and a forward reference resolves. The envelope is judged once. `Context.coerce` runs the metadata-field check, reports it only when the model layer has not, and does not read an entity whose value names none or whose configuration is not an object; the entity no longer adds "expected a metadata field" beside the model's report of the same defect, nor "requires a configuration" beside "expected a mapping". `ArrayDocumentV3.from_json` reads whatever the structure allowed, so a structural problem no longer hides the semantic ones. A rule about the member the envelope's name carries lands on the field, not under a `configuration` the document does not have. A name resolves by asking each entity of the kind whether it is its own, in registration order; the invented-identifier veto is gone. `Opaque` answers `to_json` and `canonical` -- the JSON it kept, and itself -- so `written` and `canonicalized` are gone and a field typed `CodecEntity | Opaque` is written and simplified without asking which it holds. In the parser: `Literal[1]` refuses JSON `true` (`True == 1` in Python); a `Literal` of mixed types sorts by repr instead of failing on a comparison; a fixed-length array accepts a list as a homogeneous one does; a key inside a nested object is reported at the key, as a top-level one is; a record dataclass that refuses its own values is reported, located under the object, rather than raised out of `coerce`. Over the 40,000-document corpus, no verdict changes. Lost: 18,067 duplicate reports of a malformed envelope, 1,263 "requires a configuration" beside "expected a mapping", 340 judgments of an entity whose configuration was not an object (it was judged as having none), and 42 nested-key locations, gained back at the key. Assisted-by: ClaudeCode:claude-fable-5-1 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… is an argument A parser took the scope it read nested entities in through a closure, so nothing compiled could outlive one read: every read resolved the class's annotations and compiled every field's parser again, and a document read cost eighty times a `json.loads` of the same bytes. The parser now takes the reading it runs in as an argument, generic in what the caller passes -- `Parser[S]` is `(value, loc, state) -> parsed` -- and hands it down untouched to the parsers it is built from. The entity layer's one shape, a field holding another entity, reads its scope and its problem sink off a `_Reading`. So a class's plan -- each field's parser, whether it is optional, whether the envelope's name fills it -- is a pure function of the class, compiled once and cached with `functools.cache`, as the class's resolved annotations now are. The scope's `coerce` also stops re-judging an envelope the model layer has judged. Measured on a document with a shard, six codecs and a nested pipeline: 199 µs to 85 µs per read; one codec, 21 µs to 6 µs. The 40,000-document differential is unchanged to the problem. Assisted-by: ClaudeCode:claude-fable-5-1 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…hecked` The constructor is the checked way to build an entity and stops at the first problem; `coerce` builds the record without the check and asks `problems` for every one. That second way was a private `_unchecked` taking a mapping. It is now `create_unchecked(**members)`, a documented classmethod, for any reader that judges afterwards and wants every problem of a hand-built entity -- the pair pydantic spells `__init__` and `model_construct`. Assisted-by: ClaudeCode:claude-fable-5-1 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`writer_for` is the parser's inverse over the same field annotation: what `coerce` reads from a document, `to_json` puts back. The base writes every entity -- the bare name when every member it holds is absent, the object otherwise, a contained entity through its own `to_json`, a JSON-valued member copied -- and the 34 hand-written `to_json` methods, each an instance of that rule, are gone, along with the 26 in the tests and the one in the door's example. An entity whose JSON is not its fields may still override; none does. What is given up is the per-entity return type. Every `to_json` is typed as the metadata field union rather than as the entity's own TypedDict, so a consumer who wants a member of the written form typed narrows it, as the tests now do through their helpers. The public `*Configuration` TypedDicts stay as the JSON types, and one test ties each to its entity's fields -- same keys, same requiredness -- which nothing did before. Assisted-by: ClaudeCode:claude-fable-5-1 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The metadata is `{name, configuration}`, and the entity now has that
shape: the name is the class, and its one field, `configuration`, names
a frozen dataclass of the members. The lift of configuration keys to the
entity, which nothing in Python expressed, is gone with the per-member
loop in `coerce` that did it: the configuration is parsed against the
record by the parser's record shape, and written back by the writer's.
Each entity lifts its members back to the top level with a `@property`
per member, and `with_configuration(**changes)` is the entity with
members of its configuration replaced, checked as any construction is.
Hand construction reads `GzipCodec(GzipOptions(level=5))`. Every
record is named `<Entity>Options`, since `<Entity>Configuration` is the
public JSON TypedDict; the name is a placeholder. The 40,000-document
differential is unchanged to the problem.
Assisted-by: ClaudeCode:claude-fable-5-1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A configuration record subclasses `Configuration`, and the rules on its members are its `problems` method, yielding each as found: `BloscOptions(...).problems()` answers without an entity. The entity's constructor stops at the first; `coerce` asks the record before it builds anything and reports every one, so `create_unchecked` and the `object.__new__` behind it are gone. A family's rule about its name -- `r<N>` a multiple of 8 -- is the entity's `name_problems(name)`, a classmethod, since a name is not configuration. The module-level rule functions, their quoted forward references and positional-only markers, and the `problems = fn` bindings go with them. No verdict or problem changes over the corpus. Assisted-by: ClaudeCode:claude-fable-5-1 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… a configuration An entity whose metadata carries a configuration adds `Configured` beside its kind -- `class GzipCodec(BytesBytesCodec, Configured)` -- which declares the `configuration` field as a `Configuration` and is what the base branches on: the plan, the constructor's check, `coerce` and `to_json` ask `issubclass(cls, Configured)` rather than whether a field happens to be there, and read the record as a typed attribute rather than through `getattr`. `with_configuration` lives on it, since only such an entity has members to replace. The entity narrows the field to its own record, which pyright accepts for a frozen dataclass. Registration refuses a `configuration` declared without the marker. Assisted-by: ClaudeCode:claude-fable-5-1 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Assisted-by: ClaudeCode:claude-fable-5-1 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ound Four reviewers (correctness, extension author, simplicity, zarrs and TensorStore parity) read 764cdc9. The corrections that need no decision: - A pipeline the document did not write as an array was read as an empty one and judged "no array->bytes codec" beside the structural problem. Nothing was read, so nothing is judged. On the 40k-document corpus that is the whole difference: 4,262 occurrences of that one message lost, every one on a document whose `codecs` is absent or not an array and which keeps its structural problem there; 0 verdict flips, 0 problems gained. - A name in scope but of another kind -- `transpose` where a `BytesBytesCodec` is asked for -- came back `out_of_scope`, for a reader to resolve elsewhere. It is `invalid`, with a problem that names the kind it is. - The checker: a `Literal` of booleans had the shape of integers, and a `Literal` mixing shapes had one it did not, so a union could send a value to the wrong branch; describing a mixed `Literal` crashed in `sorted`; `is_class_var` missed `t.ClassVar` under PEP 649; `field_hints` handed out its cached dict; a fixed tuple of the wrong length raised `zip`'s error rather than the JSON one. - Registration refuses two more things, with a message: a member that is itself a `Configuration`, whose rules nothing would ask, and a `__post_init__` on a record, which would stop at the first problem where `coerce` reports every one. A `NameError` inside a nested record gets the message an entity's gets. - `Extents` was a string alias, so `Extents | None` raised in an extension's annotation. - The `*Options` records are exported from their modules, since hand construction needs them; the public-name grammar learns the role. - Prose. The door's example is complete and runs as written, and says what `transition` may return, what `storage_class()` answers, how a family is written, and that only `| UNSET` makes a member optional to a document. The boundary paragraph of 4379.feature.7 described a fallback the branch does not have: an ill-typed optional member stops the entity, as the tests say, and what it contains is still read. `canonicalize_array_metadata_v3` says that a canonical form breaking its own rules raises, since the document was valid. Stale names (`blosc_problems`, `raw_bytes_problems`, `canonicalized`, `__post_init__` as the place for rules) are updated, and a duplicate import in `struct` is gone. Assisted-by: ClaudeCode:claude-fable-5-1 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…was written
`to_json` claimed fidelity and `canonical` claimed to be the one
transformation, but `to_json` already canonicalized the envelope: an
object around a bare name, an empty `configuration` and a
`must_understand` of `true` all came back simplified, while an `Opaque`
kept them verbatim. zarr-python writes `{"name": "crc32c"}` into every
sharded array, so a read-then-write respelled nearly every file.
An entity on its own still writes its own spelling, since it has no
document to be faithful to. `ArrayDocumentV3.to_json` now walks the
document it read beside what its entities write and puts each envelope
back as the document spelled it, at any depth, so a document read and
written comes out as it went in; what changed is what changes, and an
entity put in by hand writes itself. `canonical()` is the one
transformation: it rewrites the stored document into each entity's own
spelling, so writing a canonical document is the identity.
Assisted-by: ClaudeCode:claude-fable-5-1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…red is gone `Configured` marked the entities that take a configuration, and the layer branched on it in five places, with a registration refusal to keep the marker and the annotation saying the same thing. The spec makes an absent configuration and an empty one the same, so the branch was never about the metadata: every entity holds a `Configuration`, and a bare-name entity holds the empty one. `MetadataEntity` declares `configuration: Configuration`. An entity with members narrows it to its own record, its one positional argument; an entity of a bare name defaults it to the empty record with `field(default_factory=Configuration)`, keyword-only where a carried name is the positional argument. The plan reads the record or nothing, `coerce` builds every entity the same way, and `with_configuration` lives on the base and refuses an unknown member the way `replace` does. Pyright still catches a `configuration` narrowed to the wrong thing, now for every entity. The base field has no default because pyright refuses a positional child field over a defaulted base field. The default therefore lives on the bare entities, where a forgotten one is a missing argument that pyright reports at the call. Unchanged to the 40k-document corpus: 0 verdicts, 0 problems. Assisted-by: ClaudeCode:claude-fable-5-1 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ty lifts are gone Every configuration member was declared three times: a field on the record, a key on the public TypedDict, and a `@property` on the entity that returned the field. The property carried no information -- pyright cannot derive it, so each was hand-written -- and seventeen of the twenty-eight were read by nothing in the package. The one read path is now `codec.configuration.level`, the shape the metadata has, and an entity's own methods read `self.configuration.x` the same way. The worked extensions in the tests drop their lifts too, so the door shows one pattern. Unchanged to the 40k-document corpus: 0 verdicts, 0 problems. Assisted-by: ClaudeCode:claude-fable-5-1 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ong type Until now no construction path checked types at runtime. Pyright was the type checker, and `with_configuration(**changes: object)` is the one path it cannot see through: `codec.with_configuration(level=5.0)` built an entity whose rules passed and whose document a reader would refuse. `replace` on a record was the same hole with a longer name. `Configuration.__post_init__` now checks every member against its annotation with the parsers a document is read by, given a leaf for Python values: a member typed `Kind | Opaque` holds an instance of the kind or an `Opaque`, a member typed as a record holds an instance of it, checked in turn, and an optional member may be `UNSET`. An unknown key in a mapping member is the reader's report, not a type, and passes as `coerce` lets it pass. `with_configuration` needs no code of its own: `replace` rebuilds the record through that constructor and the entity through its own, so every path checks types and values. The parser builds the record through the same constructor, so a document read checks types twice. Measured in one process, an unchecked builder for the parser saved 8% of a document read and 15% of a single codec, and was dropped: not a big enough win to be worth an unchecked way to build a record. Unchanged to the 40k-document corpus: 0 verdicts, 0 problems, 0 crashes. Assisted-by: ClaudeCode:claude-fable-5-1 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…built value checks itself The annotations were meant to be the type judgment, and they were, for a document. Built by hand, nothing at runtime held an entity to the record it declares: `GzipCodec(BloscOptions(...))` built, and wrote blosc's members under gzip's name. The entity's constructor now refuses a record that is not its own and a carried name that is not a string, before any rule reads them. The other values the reader builds get the same treatment, so a hand built one is what it says: an `Opaque` refuses a reason the reader does not give, and `ArrayDocumentV3` refuses a field holding anything but an entity of its kind or an `Opaque`, located at the field. One function answers "is this an entity of that kind, or an Opaque" for records and documents alike. Unchanged to the 40k-document corpus: 0 verdicts, 0 problems, 0 crashes. A document read now costs about a tenth more than before the runtime type checks existed, all of it checks that did not exist. Assisted-by: ClaudeCode:claude-fable-5-1 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ng checked The record's and the entity's constructors check types and rules, which is right for anything built by hand and redundant for the reader: the parser has type-checked every member against the same annotations, and `coerce` has run the rules, before either builds. A document read paid for each check twice. `create_unchecked(**fields)` on `Configuration` and on `MetadataEntity` is the one way around the constructors, named for what it is: a caller that has just made the checks builds through it, and nothing else does. The parser builds records through it and `coerce` builds entities, so a read makes each check once. A document read costs what it did before any runtime check existed. Unchanged to the 40k-document corpus: 0 verdicts, 0 problems, 0 crashes. Assisted-by: ClaudeCode:claude-fable-5-1 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…he class owns its routine The scope was an object with a reading of its own, `Context.coerce`, beside the class's `coerce`, and the two judged the same field differently. The scope is now a value with one question, `claimant(kind, name)`: which class in scope a name belongs to. `resolve(data, kind, context)` is the reader. It relates the identifier in the field to a concrete class through the context, judges the envelope once for every field, and hands the class the field, since the class owns its validation routine. That routine, `coerce`, says what it is: the configuration parsed and the rules asked, the envelope being the field's and `resolve`'s. Unchanged to the 40k-document corpus: 0 verdicts, 0 problems. Assisted-by: ClaudeCode:claude-fable-5-1 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…the resolved pipeline Reading a v3 array document is now three explicit layers, ordered by what each needs and each handing the next a typed value with its problems. `well_formed_array_v3(value)` needs only the value: `refine_json` turns it into JSON with arrays as tuples, string keys and finite floats in one walk, or says at which leaves it is not, and the model layer judges the document's shape. Nothing downstream normalizes or checks JSON-ness again: the `envelope_judged` flag and the second normalization inside `coerce` are gone, `Opaque.json` is `JSONValue` and checked, and the class routine takes refined JSON. `read_array_v3(document, context)` needs a scope: each extension point is handed to `read_field`, which is what `resolve` does once a field's envelope is judged. `resolve(data, kind, context)` is the first two layers for a field on its own. `refine_array_v3(array)` needs the array, and returns `RefinedArrayV3`: the `ArrayParts` the pipeline is handed and a `Pipeline` of `PipelineStage`s, each a codec and the array that reaches it, a shard's `codecs` and `index_codecs` refined inside its stage. This is what a codec pipeline is built from; validating the composition is what the walk finds on the way. A codec that holds pipelines declares them through `inner_pipelines(incoming)` and judges nothing inside them itself, so the sharding codec's own walk is gone with `chain_problems`. Unchanged to the 40k-document corpus: 0 verdicts, 0 problems, 0 crashes. Assisted-by: ClaudeCode:claude-fable-5-1 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…te number zarr-python writes attributes with Python's `json` defaults, so a NaN `_FillValue` or CF `missing_value` is stored as a bare `NaN`. Every layer refused such a document, and the store reader could not decode it, so the consumer this layer is for could not read its own stores. `refine_node_json` is `refine_json` for a node document: a non-finite number is the float it is inside `attributes`, including the nodes a v3 group's inline `consolidated_metadata` holds, and not JSON anywhere else. The documents' first layer and the rules door read through it; `_validate_attributes` and the `is_*` guards agree with it. The store reader and writer judge what may be stored at a key by what the key stores (`stored_json_problems`): a `zarr.json` is a node document, a `.zattrs` is user data, a `.zmetadata` holds documents keyed the same way, a `.zarray` or `.zgroup` holds none. The reader now locates a non-finite number where it may not be instead of failing to decode, and the writer still refuses one, since a model built by hand is not validated. `is_json`, `validate_json` and `refine_json` stay RFC 8259. Unchanged to the 40k-document corpus: 0 verdicts, 0 problems, 0 crashes. Assisted-by: ClaudeCode:claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… understand The spec has a reader refuse an array carrying a top-level field it does not recognize unless the field says `must_understand: false`. The model partitions by that obligation (`must_understand_fields`) and leaves recognition to the reader; the read document offered no way to ask, so a consumer had to walk the raw document. It now has the same property. A verdict at the second layer was the alternative. It would close the top-level namespace the model keeps open (pinned by `test_v3_array_schema_allows_unknown_extension_fields`), and treat an unknown field unlike an unknown codec, which comes back `Opaque` for its consumer to judge. The door's reading example called `array.parts`, which the third layer owns now; it asks this instead. Unchanged to the 40k-document corpus: no verdict changes. Assisted-by: ClaudeCode:claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e it receives The registry says `offset` and `scale` are each "encoded to JSON using the Zarr V3 fill value encoding for the input array's data type". The entity took any JSON value and nothing asked the data type, so the string "0" passed as a float32 scale -- the shape of the zarr-python bug where a string zero slipped past its zero check. `incoming_problems` asks the type that reaches the codec, which after a `cast_value` is the type cast to, to judge each scalar as a fill value, and refuses the codec on a type without arithmetic: the registry lists the integer and floating-point types, so the allow-list is the two families. A zero `scale` is not refused: the spec does not forbid one, and only a reader that decodes every spelling of zero can say which scalars are zero. Against the 40k-document corpus: 19 documents valid before are invalid, each for a scalar no data type could hold; 45 problems gained, none lost. Assisted-by: ClaudeCode:claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The fragment quoted 4,397 fewer and 15,259 more problems among documents invalid under both trees, counted at an earlier head; no counting reproduces them at 9c844e2, where the duplicate-envelope and phantom-pipeline reports were already gone. Re-derived per entry point against c0600ee at this head: 0 invalid->valid, 21 valid->invalid (2 `must_understand: false`, 19 `scale_offset` scalars), 6,382 fewer, 655 more. Assisted-by: ClaudeCode:claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`out_of_range: "wrap"` is defined only for integral targets with a two's complement representation. A modelled non-integer target -- `bool` included, which is not two's complement -- is rejected; a target out of scope declines, since it may be an integral extension type. Re-landed on the current entity layer. The rule is the `cast_value` record's `problems`, and whether a type wraps is a fact each data type states, `twos_complement`, owed by `DataTypeEntity`: registration refuses a data type that has not decided, so there is no table of names for a test to keep in step. Entities also gain `name`, the spelling a document writes, distinct from `identifier`, the key they are tabled under. They differ only for the raw-bytes family, whose identifier is invented; the `bytes` and `scale_offset` messages now name an `r24` as `r24` too. Unchanged to the 40k-document corpus: 0 verdicts, 0 problems, 0 crashes. Assisted-by: ClaudeCode:claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
d-v-b
force-pushed
the
zarr-metadata/rules-wrap-blosc
branch
from
September 22, 2026 19:06
1c3cf93 to
eab9ec7
Compare
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
🤖 AI text below 🤖
Depends on #4379, and stacked on it: this targets
main, so its diff includes #4379's commits. Review only the last commit, or the diff against #4379's branch: d-v-b/zarr-python@zarr-metadata/rules-layer...zarr-metadata/rules-wrap-bloscWhat this adds
cast_value'sout_of_range: "wrap"is rejected for targets it is not defined on.The spec permits wrapping only for integral targets with a two's complement representation. A modelled non-integer target reports
invalid_valueatout_of_range—boolincluded, since it is not two's complement. A target this reader does not model declines, because it may be an integral extension type:Where the fact lives
Whether a type wraps is something the data type says about itself,
twos_complement, andDataTypeEntityrequires it. A data type that does not decide fails at import:Required rather than defaulted on purpose: either default answers for a new data type silently, and one of them accepts a cast the spec does not define.
That replaces what this rule carried before #4379 was rebuilt — two frozensets of data type names, plus a registry test asserting they partition every modelled type so that none could be accepted by omission. There is no longer a table to partition, so there is nothing for a test to keep in step.
Also here
Entities gain
name, the spelling a document writes, as distinct fromidentifier, the key they are tabled under. The two differ only for the raw-bytes family, whose identifier is invented and belongs in no message a reader sees — this rule was reportinggot 'r<N>'for a document that saidr24.🤖 Generated with Claude Code